'use strict'

const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin')
const { storage } = require('../../datadog-core')
const { getEnvironmentVariable } = require('../../dd-trace/src/config-helper')

const {
  TEST_STATUS,
  TEST_PARAMETERS,
  finishAllTraceSpans,
  getTestSuitePath,
  getTestParametersString,
  getTestSuiteCommonTags,
  addIntelligentTestRunnerSpanTags,
  TEST_SOURCE_START,
  TEST_ITR_UNSKIPPABLE,
  TEST_ITR_FORCED_RUN,
  TEST_CODE_OWNERS,
  ITR_CORRELATION_ID,
  TEST_SOURCE_FILE,
  TEST_IS_NEW,
  TEST_IS_RETRY,
  TEST_EARLY_FLAKE_ENABLED,
  TEST_EARLY_FLAKE_ABORT_REASON,
  MOCHA_IS_PARALLEL,
  TEST_IS_RUM_ACTIVE,
  TEST_BROWSER_DRIVER,
  TEST_RETRY_REASON,
  TEST_MANAGEMENT_ENABLED,
  TEST_MANAGEMENT_IS_QUARANTINED,
  TEST_MANAGEMENT_IS_DISABLED,
  TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX,
  TEST_HAS_FAILED_ALL_RETRIES,
  TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED,
  TEST_RETRY_REASON_TYPES,
  TEST_IS_MODIFIED,
  isModifiedTest
} = require('../../dd-trace/src/plugins/util/test')
const { COMPONENT } = require('../../dd-trace/src/constants')
const {
  TELEMETRY_EVENT_CREATED,
  TELEMETRY_EVENT_FINISHED,
  TELEMETRY_CODE_COVERAGE_STARTED,
  TELEMETRY_CODE_COVERAGE_FINISHED,
  TELEMETRY_ITR_FORCED_TO_RUN,
  TELEMETRY_CODE_COVERAGE_EMPTY,
  TELEMETRY_ITR_UNSKIPPABLE,
  TELEMETRY_CODE_COVERAGE_NUM_FILES,
  TELEMETRY_TEST_SESSION
} = require('../../dd-trace/src/ci-visibility/telemetry')

const BREAKPOINT_SET_GRACE_PERIOD_MS = 200

class MochaPlugin extends CiPlugin {
  static id = 'mocha'

  constructor (...args) {
    super(...args)

    this._testTitleToParams = {}
    this.sourceRoot = process.cwd()

    this.addSub('ci:mocha:test-suite:code-coverage', ({ coverageFiles, suiteFile }) => {
      if (!this.libraryConfig?.isCodeCoverageEnabled) {
        return
      }
      const testSuite = getTestSuitePath(suiteFile, this.sourceRoot)
      const testSuiteSpan = this._testSuiteSpansByTestSuite.get(testSuite)

      if (!coverageFiles.length) {
        this.telemetry.count(TELEMETRY_CODE_COVERAGE_EMPTY)
      }

      const relativeCoverageFiles = [...coverageFiles, suiteFile]
        .map(filename => getTestSuitePath(filename, this.repositoryRoot || this.sourceRoot))

      const { _traceId, _spanId } = testSuiteSpan.context()

      const formattedCoverage = {
        sessionId: _traceId,
        suiteId: _spanId,
        files: relativeCoverageFiles
      }

      this.tracer._exporter.exportCoverage(formattedCoverage)
      this.telemetry.ciVisEvent(TELEMETRY_CODE_COVERAGE_FINISHED, 'suite', { library: 'istanbul' })
      this.telemetry.distribution(TELEMETRY_CODE_COVERAGE_NUM_FILES, {}, relativeCoverageFiles.length)
    })

    this.addBind('ci:mocha:test-suite:start', (ctx) => {
      const { testSuiteAbsolutePath, isUnskippable, isForcedToRun, itrCorrelationId } = ctx

      // If the test module span is undefined, the plugin has not been initialized correctly and we bail out
      if (!this.testModuleSpan) {
        return
      }
      const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.sourceRoot)
      const testSuiteMetadata = getTestSuiteCommonTags(
        this.command,
        this.frameworkVersion,
        testSuite,
        'mocha'
      )
      if (isUnskippable) {
        testSuiteMetadata[TEST_ITR_UNSKIPPABLE] = 'true'
        this.telemetry.count(TELEMETRY_ITR_UNSKIPPABLE, { testLevel: 'suite' })
      }
      if (isForcedToRun) {
        testSuiteMetadata[TEST_ITR_FORCED_RUN] = 'true'
        this.telemetry.count(TELEMETRY_ITR_FORCED_TO_RUN, { testLevel: 'suite' })
      }
      testSuiteMetadata[TEST_SOURCE_FILE] = this.repositoryRoot !== this.sourceRoot && !!this.repositoryRoot
        ? getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
        : testSuite
      if (testSuiteMetadata[TEST_SOURCE_FILE]) {
        testSuiteMetadata[TEST_SOURCE_START] = 1
      }

      const codeOwners = this.getCodeOwners(testSuiteMetadata)
      if (codeOwners) {
        testSuiteMetadata[TEST_CODE_OWNERS] = codeOwners
      }

      const testSuiteSpan = this.tracer.startSpan('mocha.test_suite', {
        childOf: this.testModuleSpan,
        tags: {
          [COMPONENT]: this.constructor.id,
          ...this.testEnvironmentMetadata,
          ...testSuiteMetadata
        },
        integrationName: this.constructor.id
      })
      this.telemetry.ciVisEvent(TELEMETRY_EVENT_CREATED, 'suite')
      if (this.libraryConfig?.isCodeCoverageEnabled) {
        this.telemetry.ciVisEvent(TELEMETRY_CODE_COVERAGE_STARTED, 'suite', { library: 'istanbul' })
      }
      if (itrCorrelationId) {
        testSuiteSpan.setTag(ITR_CORRELATION_ID, itrCorrelationId)
      }
      const store = storage('legacy').getStore()
      ctx.parentStore = store
      ctx.currentStore = { ...store, testSuiteSpan }
      this._testSuiteSpansByTestSuite.set(testSuite, testSuiteSpan)
    })

    this.addSub('ci:mocha:test-suite:finish', ({ testSuiteSpan, status }) => {
      if (testSuiteSpan) {
        // the test status of the suite may have been set in ci:mocha:test-suite:error already
        if (!testSuiteSpan.context()._tags[TEST_STATUS]) {
          testSuiteSpan.setTag(TEST_STATUS, status)
        }
        testSuiteSpan.finish()
        this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
      }
    })

    this.addBind('ci:mocha:test-suite:error', (ctx) => {
      const { error } = ctx
      const testSuiteSpan = ctx.currentStore?.testSuiteSpan

      if (testSuiteSpan) {
        testSuiteSpan.setTag('error', error)
        testSuiteSpan.setTag(TEST_STATUS, 'fail')

        ctx.parentStore = ctx.currentStore
        ctx.currentStore = { ...ctx.currentStore, testSuiteSpan }
      }

      return ctx.currentStore
    })

    this.addSub('ci:mocha:test:is-modified', ({ modifiedFiles, file, onDone }) => {
      const testPath = getTestSuitePath(file, this.repositoryRoot)
      const isModified = isModifiedTest(
        testPath,
        null,
        null,
        modifiedFiles,
        this.constructor.id
      )

      onDone(isModified)
    })

    this.addBind('ci:mocha:test:fn', (ctx) => {
      return ctx.currentStore
    })

    this.addBind('ci:mocha:test:start', (ctx) => {
      const store = storage('legacy').getStore()
      const span = this.startTestSpan(ctx)

      ctx.parentStore = store
      ctx.currentStore = { ...store, span }

      this.activeTestSpan = span

      return ctx.currentStore
    })

    this.addSub('ci:mocha:worker:finish', () => {
      this.tracer._exporter.flush()
    })

    this.addSub('ci:mocha:test:finish', ({
      span,
      status,
      hasBeenRetried,
      isLastRetry,
      hasFailedAllRetries,
      attemptToFixPassed,
      attemptToFixFailed,
      isAttemptToFixRetry,
      isAtrRetry
    }) => {
      if (span) {
        span.setTag(TEST_STATUS, status)
        if (hasBeenRetried) {
          span.setTag(TEST_IS_RETRY, 'true')
          if (isAtrRetry) {
            span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atr)
          } else {
            span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.ext)
          }
        }
        if (hasFailedAllRetries) {
          span.setTag(TEST_HAS_FAILED_ALL_RETRIES, 'true')
        }
        if (attemptToFixPassed) {
          span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'true')
        } else if (attemptToFixFailed) {
          span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'false')
        }
        if (isAttemptToFixRetry) {
          span.setTag(TEST_IS_RETRY, 'true')
          span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atf)
        }

        const spanTags = span.context()._tags
        this.telemetry.ciVisEvent(
          TELEMETRY_EVENT_FINISHED,
          'test',
          {
            hasCodeOwners: !!spanTags[TEST_CODE_OWNERS],
            isNew: spanTags[TEST_IS_NEW] === 'true',
            isRum: spanTags[TEST_IS_RUM_ACTIVE] === 'true',
            browserDriver: spanTags[TEST_BROWSER_DRIVER]
          }
        )

        span.finish()
        finishAllTraceSpans(span)
        this.activeTestSpan = null
        if (this.di && this.libraryConfig?.isDiEnabled && this.runningTestProbe && isLastRetry) {
          this.removeDiProbe(this.runningTestProbe)
          this.runningTestProbe = null
        }
      }
    })

    this.addBind('ci:mocha:test:skip', (ctx) => {
      const store = storage('legacy').getStore()
      // skipped through it.skip, so the span is not created yet
      // for this test
      if (!store) {
        const span = this.startTestSpan(ctx)

        ctx.parentStore = store
        ctx.currentStore = { ...store, span }

        this.activeTestSpan = span
      }

      return ctx.currentStore
    })

    this.addBind('ci:mocha:test:error', (ctx) => {
      const { err } = ctx
      const span = ctx.currentStore?.span

      if (err && span) {
        if (err.constructor.name === 'Pending' && !this.forbidPending) {
          span.setTag(TEST_STATUS, 'skip')
        } else {
          span.setTag(TEST_STATUS, 'fail')
          span.setTag('error', err)
        }

        ctx.parentStore = ctx.currentStore
        ctx.currentStore = { ...ctx.currentStore, span }

        this.activeTestSpan = span
      }

      return ctx.currentStore
    })

    this.addSub('ci:mocha:test:retry', ({ span, isFirstAttempt, willBeRetried, err, test, isAtrRetry }) => {
      if (span) {
        span.setTag(TEST_STATUS, 'fail')
        if (!isFirstAttempt) {
          span.setTag(TEST_IS_RETRY, 'true')
          if (isAtrRetry) {
            span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atr)
          } else {
            span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.ext)
          }
        }
        if (err) {
          span.setTag('error', err)
        }

        const spanTags = span.context()._tags
        this.telemetry.ciVisEvent(
          TELEMETRY_EVENT_FINISHED,
          'test',
          {
            hasCodeOwners: !!spanTags[TEST_CODE_OWNERS],
            isNew: spanTags[TEST_IS_NEW] === 'true',
            isRum: spanTags[TEST_IS_RUM_ACTIVE] === 'true',
            browserDriver: spanTags[TEST_BROWSER_DRIVER]
          }
        )
        if (isFirstAttempt && willBeRetried && this.di && this.libraryConfig?.isDiEnabled) {
          const probeInformation = this.addDiProbe(err)
          if (probeInformation) {
            const { file, line, stackIndex } = probeInformation
            this.runningTestProbe = { file, line }
            this.testErrorStackIndex = stackIndex
            test._ddShouldWaitForHitProbe = true
            const waitUntil = Date.now() + BREAKPOINT_SET_GRACE_PERIOD_MS
            while (Date.now() < waitUntil) {
              // TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved.
              // However, Mocha doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to
              // fall back to a fixed syncronous delay.
            }
          }
        }

        span.finish()
        finishAllTraceSpans(span)
      }
    })

    this.addSub('ci:mocha:test:parameterize', ({ title, params }) => {
      this._testTitleToParams[title] = params
    })

    this.addSub('ci:mocha:session:finish', ({
      status,
      isSuitesSkipped,
      testCodeCoverageLinesTotal,
      numSkippedSuites,
      hasForcedToRunSuites,
      hasUnskippableSuites,
      error,
      isEarlyFlakeDetectionEnabled,
      isEarlyFlakeDetectionFaulty,
      isTestManagementEnabled,
      isParallel
    }) => {
      if (this.testSessionSpan) {
        const { isSuitesSkippingEnabled, isCodeCoverageEnabled } = this.libraryConfig || {}
        this.testSessionSpan.setTag(TEST_STATUS, status)
        this.testModuleSpan.setTag(TEST_STATUS, status)

        if (error) {
          this.testSessionSpan.setTag('error', error)
          this.testModuleSpan.setTag('error', error)
        }

        if (isParallel) {
          this.testSessionSpan.setTag(MOCHA_IS_PARALLEL, 'true')
        }

        if (isTestManagementEnabled) {
          this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true')
        }

        addIntelligentTestRunnerSpanTags(
          this.testSessionSpan,
          this.testModuleSpan,
          {
            isSuitesSkipped,
            isSuitesSkippingEnabled,
            isCodeCoverageEnabled,
            testCodeCoverageLinesTotal,
            skippingCount: numSkippedSuites,
            skippingType: 'suite',
            hasForcedToRunSuites,
            hasUnskippableSuites
          }
        )

        if (isEarlyFlakeDetectionEnabled) {
          this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true')
        }
        if (isEarlyFlakeDetectionFaulty) {
          this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ABORT_REASON, 'faulty')
        }

        this.testModuleSpan.finish()
        this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module')
        this.testSessionSpan.finish()
        this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session')
        finishAllTraceSpans(this.testSessionSpan)
        this.telemetry.count(TELEMETRY_TEST_SESSION, {
          provider: this.ciProviderName,
          autoInjected: !!getEnvironmentVariable('DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER')
        })
      }
      this.libraryConfig = null
      this.tracer._exporter.flush()
    })

    this.addBind('ci:mocha:global:run', (ctx) => {
      return ctx.currentStore
    })
  }

  startTestSpan (testInfo) {
    const {
      testName,
      testSuiteAbsolutePath,
      title,
      isNew,
      isEfdRetry,
      testStartLine,
      isParallel,
      isAttemptToFix,
      isDisabled,
      isQuarantined,
      isModified
    } = testInfo

    const extraTags = {}
    const testParametersString = getTestParametersString(this._testTitleToParams, title)
    if (testParametersString) {
      extraTags[TEST_PARAMETERS] = testParametersString
    }

    if (testStartLine) {
      extraTags[TEST_SOURCE_START] = testStartLine
    }

    if (isParallel) {
      extraTags[MOCHA_IS_PARALLEL] = 'true'
    }

    if (isAttemptToFix) {
      extraTags[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] = 'true'
    }

    if (isDisabled) {
      extraTags[TEST_MANAGEMENT_IS_DISABLED] = 'true'
    }

    if (isQuarantined) {
      extraTags[TEST_MANAGEMENT_IS_QUARANTINED] = 'true'
    }

    if (isModified) {
      extraTags[TEST_IS_MODIFIED] = 'true'
      if (isEfdRetry) {
        extraTags[TEST_IS_RETRY] = 'true'
        extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.efd
      }
    }

    const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.sourceRoot)
    const testSuiteSpan = this._testSuiteSpansByTestSuite.get(testSuite)

    extraTags[TEST_SOURCE_FILE] = this.repositoryRoot !== this.sourceRoot && !!this.repositoryRoot
      ? getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
      : testSuite

    if (isNew) {
      extraTags[TEST_IS_NEW] = 'true'
      if (isEfdRetry) {
        extraTags[TEST_IS_RETRY] = 'true'
        extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.efd
      }
    }

    return super.startTestSpan(testName, testSuite, testSuiteSpan, extraTags)
  }
}

module.exports = MochaPlugin
